PHP安全:SQL注入漏洞防护
文章来源:计算机与网络安全
SQL注入是最危险的漏洞之一,但也是最好防护的漏洞之一。本文介绍在PHP的编码中合理地使用MySQL提供的预编译进行SQL注入防护,在PHP中使用PHP数据对象扩展或MySQLi扩展连接数据库,并且对SQL语句进行预编译处理。
如果在一些项目中无法使用预编译来防止SQL注入,可以采用传统方法来验证用户的输入是否合法,严格控制输入参数的数据类型,过滤非法字符,拦截带有SQL语法的参数传入应用程序,在一定程度上提高恶意攻击者的攻击成本,但是往往容易被绕过。
1、MySQL预编译处理
一个完整的MySQL预编译处理分为编译、执行、释放三步,预编译遵循指令和数据分离的原则,可以有效地防止SQL注入的发生。
首先是编译,通过PREPARE stmt_name FROM preparable_stm来预编译一条SQL语句。
mysql>prepare test from 'insert into hacker select ?,?,?,?';
Query OK, 0 rows affected(0.00 sec)
statement prepared
通过EXECUTE stmt_name [USING @var_name [,@var_name]…]的语法来执行预编译语句。
mysql> set @name='hacker',@email='hello@ptpress.com.cn',@password='asdfghjkl',@status=1;
Query OK, 0 rows affected(0.00 sec)
mysql> execute test using @name,@email,@password,@status;
Query OK, 1 rows affected(0.01 sec)
Records:1 Duplicates: 0 Warnings: 0
mysql> select * from hacker;
+------+------+------+------+------+
|id|name|email|password|status
+------+------+------+------+------+
|1|hacker|hello@ptpress.com.cn|asdfghjkl|1
+------+------+------+------+------+
1 row in set(0.00 sec)
可以看到,数据已经被成功地插入表中。
MySQL中的预编译语句作用域是会话级,但可以通过max_prepared_stmt_count变量来控制全局最大存储的预编译语句。
mysql> set @global.max_perpared_stmt_count=1;
Query OK, 0 rows affected(0.00 sec)
mysql> perpare selecttest from 'select * from t';
ERROR 1461(42000): Can't create more than max_prepared_stmt_count statements(current value: 1)
当预编译条数达到阈值时,可以看到MySQL会报出如上所示的错误。
如果要释放一条预编译语句,则可以使用{DEALLOCATE | DROP} PREPARE stmt_name的语法进行操作。
mysql> deallocate prepare test;
Query OK, 0 rows affected(0.00 sec)
使用Wireshark抓包工具可捕获到MySQL预编译的执行过程,如图1所示。
图1 MySQL抓包
从捕获到的流量中可以看到,每次SQL执行会分两次进行。第一次先将需要编译的SQL语句发送给数据库进行编译,数据部分用占位符代替。第二次将用户数据提交给数据库执行。SQL语句不会再次进行编译,即使用户数据中包含SQL字符也会被当成数据处理,不会改变原语句的结构。
2、PHP使用MySQL的预编译处理
SQL之所以能被注入,主要原因在于它的数据和代码指令是混合的。使用数据库预编译方式进行数据库查询,不仅可以增强系统安全性,而且可以提高系统的执行效率。当一个SQL语句需要执行多次时,使用预编译语句可以减少处理时间,提高执行效率。在PHP系统中可以通过PDO模块或MySQLi模块进行SQL预编译处理,下面依次举例说明使用方式。
(1)PDO的预编译处理举例
<?php
$dns='mysql:dbname=safe;host=127.0.0.1';
$user='root';
$password='123456';
try {
$pdo=new PDO($dns,$user,$password);
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES,false);
$pdo->setAttribute(PDO::ATTR_ERRMODE,PDO::ERRMODE_EXCEPTION);
}
catch(PDOException $e)
{
echo $e->getMessage();
}
$pdo->query("set names utf8");
$sql='insert into hacker(name,email) values(:name,:email)';
// 编译SQL
$pdo_stmt=$pdo->prepare($sql);
$name="hacker attack";
$email="safe@ptpress.com.cn";
// 绑定参数
$pdo_stmt->bindParam(':name',$name);
$pdo_stmt->bindParam(':email',$email);
$pdo_stmt->execute();
if($pdo_stmt->errorCode()==0)
{
echo "数据插入成功";
}
else
{
print_r($pdo_stmt->errorInfo());
}
在默认情况下,使用PDO并没有让MySQL数据库执行真正的预处理语句。为了解决这个问题,应该禁止PDO模拟预处理语句,添加PDO::ATTR_EMULATE_PREPARES、PDO::ATTR_ERRMODE属性。
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES,false);
$pdo->setAttribute(PDO::ATTR_ERRMODE,PDO::ERRMODE_EXCEPTION);
(2)MySQLi的预编译处理举例
<?php
$mysqli=new mysqli("localhost","root","","safe");
if(mysqli_connect_errno())
{
printf("Connect failed: %s\n",mysqli_connect_error());
exit();
}
$mysqli->query("set names utf8");
$sql='insert into author(name,email)values(?,?)';
$mysqli_stmt=$mysqli->prepare($sql);
$mysqli_stmt->bind_param('ss',$name,$email);
$name="hacker attack";
$email="safe@ptpress.com.cn";
$res=$mysqli_stmt->execute();
if(!$res)
{
echo '错误:'.$mysqli_stmt->error;
}
else
{
echo "数据插入成功";
}
$mysqli_stmt->close();
$mysqli->close();
由于预处理是先提交SQL语句到MySQL服务端,执行预编译,客户端需要执行SQL语句时只需上传输入参数,分离了参数与SQL语句,因此不会导致恶意参数的执行,从根本上保障了数据库的安全。
3、校验和过滤
为了有效防止SQL注入,应尽量使用MySQL的预编译处理,不要使用动态拼装SQL。如果既有的系统中已经存在一些历史代码动态拼装SQL的情况,并且业务逻辑复杂,不能及时地更改为预编译处理形式,或者存在PHP版本较低、数据库版本比较老的情况,不支持预编译处理,为了防止前文提到的普通注入、隐式类型注入、盲注、二次解码注入,需要对输入的数据进行有效的校验和过滤。
通常使用的校验方式是判断传入的数据类型是否合法,如果不是所需要的要及时中断程序,防止继续执行。下面的示例中对传入的数据类型进行判定。
<?php
$id=$_GET['id'];
if(empty($id))
{
die('参数不能为空,请重新输入!');
}
if(gettype($id)!='integer')
{
die('非法的数据类型,请重新输入!');
}
if($id<=0)
{
die('输入的数据超出范围内,请重新输入!');
}
表1所列是一些常用的校验变量函数,这些函数通常用于校验用户传入的参数。
表1 常用的校验变量函数
除了上面的函数以外,也可以使用正则过滤SQL语句中的非法字符防止发生部分SQL注入方式,下面是代码示例。
<?php
function removeSpecialChar($param)
{
$regex="/\/|\~|\!|\@|\#|\\$|\%|\^|\&|\*|\(|\)|\_/\+|\{|\}|\:|\<|\>|\?|\[|\]|\,|\.|\/|\;|\'|\'|\-|\=|\\\|\|/";
return preg_replace($regex,"",$param);
}
$name="name' OR 'a'='a'";
$name=removeSpecialChar($name);
?>
同时还可以检查参数中是否包含SQL关键字,下面是示例代码。
<?php
eregi('select|insert|update|delete|drop|truncte|'|/*|*|../|./|union|into|load_file|outfile|union',$name);
?>
这些过滤方式都需要在特定的业务场景下使用,使用不当可能会影响到现有业务。要从根本上杜绝SQL注入漏洞,建议使用SQL预编译处理进行系统研发。
4、宽字节注入防护
要防止这类整型的宽字节注入,可以在进行SQL查询前使用intval对变量进行强制转换。
可以使用mysql_real_escape_string进行防御,在使用前需要mysql_set_charset指定当前所使用的字符集格式才能生效。
<?php
header("Content-Type: text/html;charset=UTF-8");
$conn=mysql_connect('localhost','root','') or die('数据库连接失败');
mysql_query("SET NAMES 'gbk'"); // GBK编码
mysql_select_db('safe',$conn);
mysql_set_charset('gbk',$conn);
$id=isset($_GET['id']) ? mysql_real_escape_string($_GET['id']) :1;
$sql="SELECT * FROM hacker WHERE id='{$id}'";
$result=mysql_query($sql,$conn) or die(mysql_error()); // SQL出错会报错,方便观察
$row=mysql_fetch_array($result,MYSQL_ASSOC);
print_r($row);
mysql_free_result($result);
?>
还有一种方式就是将character_set_client设置为binary,在执行SQL前先执行以下代码。
mysql_query("SET character_set_connection=gbk, character_set_results= gbk,character_set_client=binary", $conn);
将character_set_client设置成二进制格式,就不存在宽字节或多字节的问题了,所有数据以二进制的形式传递,就能有效地避免宽字符注入。
5、禁用魔术引号
PHP中的魔术引号选项magic_quotes_gpc推荐关闭,它并不能有效地防止SQL注入,已知已经有若干种方法可以绕过它,甚至由于它的存在反而衍生出一些新的安全问题。XSS、SQL注入等漏洞,都应该由应用在正确的方法中解决,同时关闭魔术引号还能提高性能。
magic_quotes_gpc=Off ; 关闭魔术引号选项
推荐阅读
*PostgreSQL 提权漏洞(CVE-2018-1058)
*Node.js 目录穿越漏洞(CVE-2017-14849)